/*
 *******************************************************************************
 *
 * Copyright (C) 2019-2020 Dialog Semiconductor.
 * This computer program includes Confidential, Proprietary Information
 * of Dialog Semiconductor. All Rights Reserved.
 *
 *******************************************************************************
 */

package com.example.suota.suota.scan;

import static com.example.suota.suota.global.SuotaProfile.Constants.BLUETOOTH_PACKAGE_NAME;
import static com.example.suota.suota.global.SuotaProfile.Constants.LOCATION_PERMISSION_CODE;
import static com.example.suota.suota.global.SuotaProfile.Constants.REQUEST_ENABLE_BT;
import static com.example.suota.suota.global.SuotaProfile.Constants.REQUEST_LOCATION_SERVICES;
import static com.example.suota.suota.global.SuotaProfile.Constants.SCAN_PERMISSION_CODE;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.BLE_NOT_SUPPORTED;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.BLUETOOTH_NOT_ENABLED;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.FAIL;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.LOCATION_PERMISSION_DENIED;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.LOCATION_SERVICES_REQUIRED;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.RESCANNING_TOO_SOON;
import static com.example.suota.suota.global.SuotaProfile.ScanFailure.SCAN_PERMISSION_DENIED;
import static com.example.suota.suota.global.SuotaProfile.ScanStatus.STARTED;
import static com.example.suota.suota.global.SuotaProfile.ScanStatus.STOPPED;
import static com.example.suota.suota.global.SuotaProfile.Uuid.SUOTA_SERVICE_UUID;

import android.Manifest;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.provider.Settings;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleObserver;
import androidx.lifecycle.OnLifecycleEvent;

import com.example.suota.suota.R;
import com.example.suota.suota.activities.BaseScanActivity;
import com.example.suota.suota.dialogs.CustomDialogFragment;
import com.example.suota.suota.dialogs.DialogFragmentBuilder;
import com.example.suota.suota.dialogs.DialogFragmentManager;
import com.example.suota.suota.dialogs.SupportCustomDialogFragment;
import com.example.suota.suota.global.SuotaLibConfig;
import com.example.suota.suota.global.SuotaLibLog;
import com.example.suota.suota.global.SuotaProfile;
import com.example.suota.suota.interfaces.callbacks.ISuotaScanner;
import com.example.suota.suota.utils.MainThread;
import com.example.suota.suota.utils.RuntimePermissionChecker;


import java.lang.ref.WeakReference;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;


public class SuotaScanner implements LifecycleObserver {
    private static final String TAG = "SuotaScanner";

    public static final String SCAN_LOCATION_PERMISSION = Build.VERSION.SDK_INT < 29 ?  Manifest.permission.ACCESS_COARSE_LOCATION : Manifest.permission.ACCESS_FINE_LOCATION;

    // Max 5 interactions (start or/and stop) can take place within 30 secs due to GattService limitations
    private static final long RESCAN_TIME_WINDOW = 30000;
    private static final int MAX_INTERACTIONS = 4;

    private IScanner scanner;
    private ISuotaScanner scanCallback;
    private BluetoothAdapter bluetoothAdapter;
    private RuntimePermissionChecker runtimePermissionChecker;
    private SupportCustomDialogFragment supportCustomDialogFragment;
    private WeakReference<AppCompatActivity> activityWeakReference;
    //private Task<LocationSettingsResponse> task;

    private boolean isScanning;
    private boolean onlySUOTAUuidSearch = true;
    private boolean skipLocationCheck;
    private Boolean locationServicesRequired;
    private boolean restoredToLocationServicesStep;
    private boolean locationServicesPromptVisible;
    private boolean pendingDialog;
    private final boolean allowDialogDisplay;
    private final boolean checkPermissions;

    private long scanTimeout;
    private final Handler scanTimeoutHandler;
    private final Runnable scanTimeoutRunnable;

    private List<UUID> uuids;

    /*
     * Fields below are being used to avoid getting BtGatt.GattService: App is scanning too frequently
     * due to the limitation of Max 5 interactions (start or/and stop) can take place within 30 secs
     */
    private List<Long> scanTimeStamps;
    private int scanInteractionCounter;
    private final Handler delayRescanHandler;
    private final Runnable rescanRunnable;
    private AtomicBoolean isRescanPending;
    private Lifecycle lifecycle;

    private void registerLifecycle(Lifecycle lifecycle) {
        this.lifecycle = lifecycle;
        lifecycle.addObserver(this);
    }

    private SuotaScanner(@NonNull Builder builder) {
        registerLifecycle(builder.lifecycle);
        scanInteractionCounter = 0;
        restoredToLocationServicesStep = false;
        locationServicesPromptVisible = false;
        skipLocationCheck = builder.skipLocationCheck;
        scanTimeout = builder.scanTimeout;
        checkPermissions = builder.checkPermissions;
        allowDialogDisplay = builder.allowDialogDisplay;
        activityWeakReference = builder.activity != null ? new WeakReference<>(builder.activity) : null;

        if (checkPermissions) {
            runtimePermissionChecker = builder.runtimePermissionChecker != null ? builder.runtimePermissionChecker : new RuntimePermissionChecker(builder.activity, builder.saveInstanceState);
            runtimePermissionChecker.registerPermissionRequestCallback(LOCATION_PERMISSION_CODE, (requestCode, permissions, denied) -> onLocationPermissionResult(denied == null));
            if (Build.VERSION.SDK_INT >= 31)
                runtimePermissionChecker.registerPermissionRequestCallback(SCAN_PERMISSION_CODE, (requestCode, permissions, denied) -> onScanPermissionResult(denied == null));
        }

        restoreDialog(builder.saveInstanceState, builder.activity);

        scanTimeoutHandler = new Handler();
        scanTimeoutRunnable = new Runnable() {
            @Override
            public void run() {
                stopScan();
            }
        };

        // Due to rescan limitation of Gatt, if the user tries to rescan too frequently,
        // we delay the rescan until its safe again to rescan.
        scanTimeStamps = new ArrayList<>();
        isRescanPending = new AtomicBoolean();
        delayRescanHandler = new Handler();
        rescanRunnable = new Runnable() {
            @Override
            public void run() {
                isRescanPending.set(false);
                scanDevices();
            }
        };

        scanner = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ? getScannerApi19() : getScannerApi21();
    }

    // region Public Getters and Setters

    /**
     * Returns if there is a scan currently running.
     *
     * @return <b>true</b> if there is a scan currently running
     */
    public boolean isScanning() {
        return isScanning;
    }

    /**
     * Sets value to skipLocationCheck flag. If set to true the library will allow start scanning
     * even if the Location Services are required but disabled.
     *
     * @param skipLocationCheck skipLocationFlag
     */
    public void setSkipLocationCheck(boolean skipLocationCheck) {
        this.skipLocationCheck = skipLocationCheck;
    }

    // endregion

    // region Public Methods: Scan Overloading

    /**
     * Scans for devices advertising SUOTA_SERVICE_UUID,
     * using the set timeout through {@link com.dialog.suotalib.scan.SuotaScanner.Builder#scanTimeout timeout}
     * otherwise the {@link com.dialog.suotalib.global.SuotaLibConfig.Default#SCAN_TIMEOUT default scan timeout}.
     *
     * If {@link com.dialog.suotalib.scan.SuotaScanner.Builder#checkPermissions check permission flag} is set to <b>true</b>
     * before actually start scanning the library will check and request if required the associated permissions.
     *
     * @param scanCallback triggered on any scan status update
     */
    public void scan(@NonNull final ISuotaScanner scanCallback) {
        this.scanCallback = scanCallback;
        if (checkPermissions) {
            if (runtimePermissionChecker == null) {
                if (SuotaLibLog.SCAN_ERROR)
                    Log.e(TAG, "Suota Permission Handler is null.");
                triggerOnFailure(FAIL);
            } else if (runtimePermissionChecker.checkPermission(SCAN_LOCATION_PERMISSION, R.string.suota_location_permission_rationale, LOCATION_PERMISSION_CODE)) {
                onLocationPermissionResult(true);
            }
        } else {
            scanDevices();
        }
    }

    /**
     * Scans for devices advertising SUOTA_SERVICE_UUID, using the given timeout.
     *
     * If {@link com.dialog.suotalib.scan.SuotaScanner.Builder#checkPermissions check permission flag} is set to <b>true</b>
     * before actually start scanning the library will check and request if required the associated permissions.
     *
     * @param scanCallback triggered on any scan status update
     * @see #scan(ISuotaScanner)
     */
    public void scan(long scanTimeout, @NonNull ISuotaScanner scanCallback) {
        this.scanTimeout = scanTimeout;
        scan(scanCallback);
    }

    /**
     * Scans for devices advertising any of the given uuids,
     * using the set timeout through {@link com.dialog.suotalib.scan.SuotaScanner.Builder#scanTimeout timeOut}
     * otherwise the {@link com.dialog.suotalib.global.SuotaLibConfig.Default#SCAN_TIMEOUT default scan timeout}.
     *
     * If {@link com.dialog.suotalib.scan.SuotaScanner.Builder#checkPermissions check permission flag} is set to <b>true</b>
     * before actually start scanning the library will check and request if required the associated permissions.
     *
     * @param scanCallback triggered on any scan status update
     * @see #scan(ISuotaScanner)
     */
    public void scan(List<UUID> uuids, @NonNull ISuotaScanner scanCallback) {
        onlySUOTAUuidSearch = false;
        this.uuids = uuids;
        scan(scanCallback);
    }

    /**
     * Scans for devices advertising any of the given uuids, using the given timeout.
     *
     * If {@link com.dialog.suotalib.scan.SuotaScanner.Builder#checkPermissions check permission flag} is set to <b>true</b>
     * before actually start scanning the library will check and request if required the associated permissions.
     *
     * @param scanCallback triggered on any scan status update
     * @see #scan(ISuotaScanner)
     */
    public void scan(List<UUID> uuids, long scanTimeout, @NonNull ISuotaScanner scanCallback) {
        this.scanTimeout = scanTimeout;
        scan(uuids, scanCallback);
    }

    // endregion

    // region Public Methods

    /**
     * Stops scan if there is a scan currently running.
     */
    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    public void stopScan() {
        if (SuotaLibLog.SCAN_DEBUG)
            Log.d(TAG, "Stop scanning");
        if (!isScanning)
            return;

        scanner.stopScanning();
        delayRescanHandler.removeCallbacks(rescanRunnable);
        scanTimeoutHandler.removeCallbacks(scanTimeoutRunnable);
        // resets
        onlySUOTAUuidSearch = true;
        uuids = null;
    }

    /**
     * If the library user app class interfering with SuotaScanner extends the {@link BaseScanActivity RunAutoSuotaActivity}
     * then there is no need to use this.
     *
     * If either {@link com.dialog.suotalib.scan.SuotaScanner.Builder#allowDialogDisplay builder allow dialog display flag} has been set to true
     * or it hasn't been set at all but {@link com.dialog.suotalib.global.SuotaLibConfig#ALLOW_DIALOG_DISPLAY defalult allow dialog display flag} is set to <b>true</b>,
     * the library handles the cases that scan requirements don't meet.
     *
     * Such case is when bluetooth is disabled.
     * In this case the library will pop a Fragment Dialog requesting to enable the bluetooth. If the user chooses to enable it,
     * the library will start an activity in order to let the user enable it.
     *
     * Thus the library user app needs to override the onActivityResult and call this method within its body,
     * passing the corresponding parameters.
     *
     * @param requestCode the requestCode param of the override onActivityResult.
     *                    Handles requestCode == 1, meaning Request Bluetooth Enable case.
     * @param resultCode  the requestCode param of the override onActivityResult
     * @param data        the data param of the override onActivityResult
     * @return true, if the request originated from SuotaScanner
     */
    public boolean onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode == REQUEST_ENABLE_BT) {
            if (resultCode == Activity.RESULT_OK) {
                scanDevices();
            } else {
                triggerOnFailure(BLUETOOTH_NOT_ENABLED);
            }
            return true;
        } else if (requestCode == REQUEST_LOCATION_SERVICES) {
            if (resultCode == Activity.RESULT_OK) {
                locationServicesPromptVisible = false;
            } else {
                skipLocationCheck = true;
                triggerOnFailure(LOCATION_SERVICES_REQUIRED);
            }
            scanDevices();
            return true;
        }
        return false;
    }

    /**
     * Takes care anything needed to be done before the release of the SuotaScanner object.
     */
    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    public void destroy() {
        if (SuotaLibLog.SCAN_DEBUG)
            Log.d(TAG, "Destroy event got triggered thus cleaning Up");
        stopScan();
        scanCallback = null;
        activityWeakReference = null;
        if (runtimePermissionChecker != null)
            runtimePermissionChecker = null;
        if (supportCustomDialogFragment != null)
            supportCustomDialogFragment.dismiss();
    }

    /**
     * If the library user app class interfering with SuotaScanner extends the {@link BaseScanActivity RunAutoSuotaActivity}
     * then there is no need to use this.
     *
     * If either {@link com.dialog.suotalib.scan.SuotaScanner.Builder#checkPermissions builder check permissions flag} has been set to true
     * or it hasn't been set at all but {@link com.dialog.suotalib.global.SuotaLibConfig#CHECK_PERMISSIONS defalult check permissions flag} is set to <b>true</b>,
     * the library checks and if needed requests the associated permission in order to be able to actually scan for devices.
     *
     * Thus the library user app needs to override the onRequestPermissionsResult and call this method within its body,
     * passing the corresponding parameters.
     *
     * @param requestCode  the requestCode of the override onRequestPermissionsResult
     * @param permissions  the permissions of the override onRequestPermissionsResult
     * @param grantResults the grantResults of the override onRequestPermissionsResult
     * @return true, if the permissions request result was handled
     */
    public boolean onRequestPermissionsResult(final int requestCode, final String[] permissions, final int[] grantResults) {
        return runtimePermissionChecker != null && runtimePermissionChecker.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }

    /**
     * If the library user app class interfering with SuotaScanner extends the {@link BaseScanActivity RunAutoSuotaActivity}
     * then there is no need to use this.
     *
     * If either {@link com.dialog.suotalib.scan.SuotaScanner.Builder#checkPermissions builder check permissions flag} has been set to true
     * or it hasn't been set at all but {@link com.dialog.suotalib.global.SuotaLibConfig#CHECK_PERMISSIONS defalult check permissions flag} is set to <b>true</b>,
     * the library checks and if needed requests the associated permission in order to be able to actually scan for devices.
     *
     * Thus the library user app needs to override the onSaveInstanceState and call this method within its body,
     * passing the corresponding parameters.
     *
     * @param outState the bundle param of the override onSaveInstanceState
     */
    public void saveState(Bundle outState) {
        if (runtimePermissionChecker != null)
            runtimePermissionChecker.saveState(outState);
        outState.putBoolean("SUOTA#SKIP_LOCATION", skipLocationCheck);
        if (supportCustomDialogFragment != null)
            outState.putParcelable("SUOTA#" + CustomDialogFragment.class.getSimpleName(), supportCustomDialogFragment.getDialogFragmentManager());
        /*if (task != null || locationServicesPromptVisible)
            outState.putBoolean("SUOTA#" + Task.class.getSimpleName(), true);*/
    }

    // endregion

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    protected void resumed() {
        if (SuotaLibLog.SCAN_DEBUG)
            Log.d(TAG, "Resumed lifecycle event triggered");
        if (!pendingDialog)
            return;

        pendingDialog = false;
        if (supportCustomDialogFragment != null)
            supportCustomDialogFragment.showDialog(activityWeakReference.get().getSupportFragmentManager());
    }

    // region Private Methods: Scan Requirements Handling

    /**
     * Checks all scan requirements.
     *
     * @param context context
     * @return true on SUCCESS, when all requirements passed the check or the Location Services requirement has been skipped but the other requirements passed the check.
     *
     * Otherwise returns false when one of the following errors occurred,
     * <li> {@link SuotaProfile.ScanFailure#BLE_NOT_SUPPORTED} if PackageManager does not have system feature BLE,
     * <li> {@link SuotaProfile.ScanFailure#BLUETOOTH_NOT_ENABLED} if bluetooth is not enabled,
     * <li> {@link SuotaProfile.ScanFailure#LOCATION_SERVICES_REQUIRED} if location service is required but not enabled.
     */
    private boolean checkRequirements(@NonNull Context context) {
        bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (bluetoothAdapter == null || !context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
            triggerOnFailure(BLE_NOT_SUPPORTED);
            return false;
        } else if (!bluetoothAdapter.isEnabled()) {
            handleBluetoothNotEnabled();
            return false;
        } else if (!locationServicesEnabled(context)) {
            handleLocationServicesRequired();
            return false;
        }
        return true;
    }

    @SuppressLint("MissingPermission")
    private void handleBluetoothNotEnabled() {
        if (allowDialogDisplay) {
            if (activityWeakReference != null && activityWeakReference.get() != null) {
                activityWeakReference.get().startActivityForResult(new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BT);
            }
        }
        triggerOnFailure(BLUETOOTH_NOT_ENABLED);
    }

    /*
     * NOTE: This check may fail on some devices, returning false although location services are required.
     * e.g. Xiaomi Mi8 Lite (Android 9), Samsung S10 (Android 9)
     */
    private boolean locationServicesRequired(@NonNull Context context) {
        if (locationServicesRequired == null) {
            locationServicesRequired = true;
            try {
                Resources res = context.getPackageManager().getResourcesForApplication(BLUETOOTH_PACKAGE_NAME);
                int id = res.getIdentifier("strict_location_check", "bool", BLUETOOTH_PACKAGE_NAME);
                locationServicesRequired = res.getBoolean(id);
            } catch (PackageManager.NameNotFoundException | Resources.NotFoundException e) {
                if (SuotaLibLog.SCAN_ERROR)
                    Log.e(TAG, "Failed to read location services requirement setting", e);
            }
            if (SuotaLibLog.SCAN_DEBUG)
                Log.d(TAG, "Location services requirement setting: " + locationServicesRequired);
        }
        return locationServicesRequired;
    }

    /**
     * Check if location service is required, and if so, if it is enabled.
     *
     * @param context context
     * @return true if you can continue scanning
     */
    private boolean locationServicesEnabled(Context context) {
        if (skipLocationCheck || Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
            return true;
        if (!locationServicesRequired(context))
            return true;
        return Settings.Secure.getInt(context.getContentResolver(), Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF) != Settings.Secure.LOCATION_MODE_OFF;
    }

    private void handleLocationServicesRequired() {
        if (allowDialogDisplay)
            requestLocationServices();
        triggerOnFailure(LOCATION_SERVICES_REQUIRED);
    }

    private void requestLocationServices() {
        if (locationServicesPromptVisible)
            return;
        if (activityWeakReference == null)
            return;
        final AppCompatActivity activity = activityWeakReference.get();
        if (activity == null)
            return;

        if (!SuotaLibConfig.CHECK_LOCATION_WITH_PLAY_SERVICES) {
            requestLocationWithSettings(activity);
        }/* else {
            requestLocationWithPlayServices(activity);
        }*/
    }

    private void requestLocationWithSettings(final AppCompatActivity activity) {
        displayEnableLocationDialog(activity, new EnableLocationServicesAction() {
            @Override
            public void perform() {
                activity.startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS));
            }
        });
    }

    /*private void requestLocationWithPlayServices(final AppCompatActivity activity) {
        // The code below, checks if location settings meet the location request defined,
        // and if not they display the default android dialog requesting to enable them,
        // like google maps and other apps do
        LocationRequest locationRequest = LocationRequest.create().setPriority(LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY);

        LocationSettingsRequest.Builder builder = new LocationSettingsRequest
                .Builder()
                .addLocationRequest(locationRequest)
                .setAlwaysShow(true)
                .setNeedBle(true);

        task = LocationServices.getSettingsClient(activity).checkLocationSettings(builder.build());
        task.addOnSuccessListener(activity, new OnSuccessListener<LocationSettingsResponse>() {
            @Override
            public void onSuccess(LocationSettingsResponse locationSettingsResponse) {
                // We should never reach updateLogcatWithElapsedTime, since we use location services only
                // in case the location services don't meet our requirements
                // However just in case we call scan devices here to start scanning
                scanDevices();
            }
        });
        task.addOnFailureListener(activity, new OnFailureListener() {
            @Override
            public void onFailure(@NonNull Exception e) {
                if (e instanceof ResolvableApiException) {
                    // Location Settings do not satisfied our requirement
                    // however we can fix it using a dialog
                    final ResolvableApiException resolvable = (ResolvableApiException) e;
                    displayEnableLocationDialog(activity, new EnableLocationServicesAction() {
                        @Override
                        public void perform() {
                            try {
                                resolvable.startResolutionForResult(activity, REQUEST_LOCATION_SERVICES);
                            } catch (IntentSender.SendIntentException exception) {
                                triggerOnFailure(SuotaProfile.ScanFailure.LOCATION_SERVICES_REQUIRED);
                                if (SuotaLibLog.SCAN_ERROR)
                                    Log.e(TAG, "SendIntentException occurred");
                            }
                        }
                    });
                } else {
                    // Pretty much impossible to occur
                    // But just in case, trigger the appropriate callback
                    triggerOnFailure(SuotaProfile.ScanFailure.LOCATION_SERVICES_REQUIRED);
                    if (SuotaLibLog.SCAN_ERROR)
                        Log.e(TAG, "Unable to resolve location requirements");
                }
            }
        });
    }*/

    private interface EnableLocationServicesAction {
        void perform();
    }

    private void displayEnableLocationDialog(final AppCompatActivity activity, final EnableLocationServicesAction action) {
        boolean restoringState = true;
        if (supportCustomDialogFragment == null) {
            restoringState = false;
            supportCustomDialogFragment = new DialogFragmentBuilder()
                    .setTitle(activity.getString(R.string.suota_location_services_prompt_title))
                    .setMessage(activity.getString(R.string.suota_location_services_prompt_msg))
                    .setPositive(activity.getString(R.string.suota_location_services_prompt_positive))
                    .setNegative(activity.getString(R.string.suota_location_services_prompt_negative))
                    .setIconResourceId(R.drawable.suota_ic_info_outline_black_36dp)
                    .buildV4();
        }
        supportCustomDialogFragment.setOnPositiveClickListener(new DialogFragmentManager.OnResponseListener() {
            @Override
            public void onActionPerformed() {
                action.perform();
                restoredToLocationServicesStep = false;
            }
        });
        supportCustomDialogFragment.setOnNegativeClickListener(new DialogFragmentManager.OnResponseListener() {
            @Override
            public void onActionPerformed() {
                restoredToLocationServicesStep = false;
                skipLocationCheck = true;
                scanDevices();
            }
        });
        supportCustomDialogFragment.setOnDismissListener(new DialogFragmentManager.OnResponseListener() {
            @Override
            public void onActionPerformed() {
                supportCustomDialogFragment = null;
            }
        });
        if (!restoringState) {
            if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
                supportCustomDialogFragment.showDialog(activity.getSupportFragmentManager());
            } else {
                pendingDialog = true;
            }
        }
    }

    // endregion

    // region Private Methods

    private void restoreDialog(Bundle saveInstanceState, AppCompatActivity activity) {
        if (activity == null || saveInstanceState == null)
            return;

        if (saveInstanceState.containsKey("SUOTA#SKIP_LOCATION"))
            this.skipLocationCheck = saveInstanceState.getBoolean("SUOTA#SKIP_LOCATION");
        if (saveInstanceState.containsKey("SUOTA#" + CustomDialogFragment.class.getSimpleName())) {
            supportCustomDialogFragment = (SupportCustomDialogFragment) activity.getSupportFragmentManager().findFragmentByTag(SupportCustomDialogFragment.class.getSimpleName());
            supportCustomDialogFragment.setDialogFragmentManager((DialogFragmentManager) saveInstanceState.getParcelable("SUOTA#" + CustomDialogFragment.class.getSimpleName()));
            requestLocationServices();
            restoredToLocationServicesStep = true;
        }
        /*if (saveInstanceState.containsKey("SUOTA#" + Task.class.getSimpleName()))
            locationServicesPromptVisible = true;*/
    }

    private void scanDevices() {
        if (SuotaLibLog.SCAN_DEBUG)
            Log.d(TAG, "Start scanning");
        if (restoredToLocationServicesStep || activityWeakReference == null)
            return;

        Context context = activityWeakReference.get();
        if (context != null && checkRequirements(context) && onPrepare())
            scanner.startScanning();
    }

    private boolean onPrepare() {
        if (SuotaLibLog.SCAN_DEBUG) {
            Log.d(TAG, scanInteractionCounter + " counter");
            Log.d(TAG, scanTimeStamps.size() + " size of timestamps");
        }
        if (isScanning)
            stopScan();
        if (scanTimeStamps.size() == 0 || canRescan()) {
            scanTimeoutHandler.postDelayed(scanTimeoutRunnable, scanTimeout);
            return true;
        }
        return false;
    }

    /*
     * Check if we can rescan now or should postpone it.
     *
     * Due to rescan limitation of Gatt, max 6 interactions (scan or/and stop) are allowed within 30 seconds.
     * So each time the user tries to scan we check if he haven't reached the limit.
     * We compare the time of the first scan taken place within the last 30 seconds and the interaction in between.

     * If we have reached the limit then we postpone the scan
     * using the delayRescanHandler.
     */
    private boolean canRescan() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
            return true;

        if (System.currentTimeMillis() - scanTimeStamps.get(0) <= RESCAN_TIME_WINDOW && scanInteractionCounter == MAX_INTERACTIONS) {
            postponeRescan();
            return false;
        }
        while (scanTimeStamps.size() > 0 && System.currentTimeMillis() - scanTimeStamps.get(0) > RESCAN_TIME_WINDOW) {
            scanTimeStamps.remove(0);
            scanInteractionCounter--;
        }
        return true;
    }

    private void postponeRescan() {
        if (isRescanPending.getAndSet(true)) {
            if (SuotaLibLog.SCAN_DEBUG)
                Log.d(TAG, "Rescan request too soon. Another rescan request is already pending... ");
            triggerOnFailure(RESCANNING_TOO_SOON);
            return;
        }

        delayRescanHandler.removeCallbacks(rescanRunnable);
        long timeOut = RESCAN_TIME_WINDOW - (System.currentTimeMillis() - scanTimeStamps.get(0));
        delayRescanHandler.postDelayed(rescanRunnable, timeOut);
        if (SuotaLibLog.SCAN_DEBUG)
            Log.d(TAG, "Rescan request too soon, wait for " + timeOut + " millis");
        triggerOnFailure(RESCANNING_TOO_SOON);
    }

    private void onLocationPermissionResult(boolean isLocationPermissionGranted) {
        if (!isLocationPermissionGranted) {
            triggerOnFailure(LOCATION_PERMISSION_DENIED);
        } else if (Build.VERSION.SDK_INT >= 31) {
            if (runtimePermissionChecker.checkPermissions(new String[] { Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }, null, SCAN_PERMISSION_CODE))
                onScanPermissionResult(true);
        } else {
            scanDevices();
        }
    }

    private void onScanPermissionResult(boolean isScanPermissionGranted) {
        if (!isScanPermissionGranted)
            triggerOnFailure(SCAN_PERMISSION_DENIED);
        else
            scanDevices();
    }

    private static List<UUID> getUuidsFromAdvertisementData(byte[] advertisedData) {
        List<UUID> uuids = new ArrayList<>();

        ByteBuffer buffer = ByteBuffer.wrap(advertisedData).order(ByteOrder.LITTLE_ENDIAN);
        while (buffer.remaining() > 2) {
            int length = buffer.get() & 0xff;
            if (length == 0)
                break;

            int type = buffer.get() & 0xff;
            --length;

            switch (type) {
                case 0x02: // Partial list of 16-bit UUIDs
                case 0x03: // Complete list of 16-bit UUIDs
                    while (length >= 2 && buffer.remaining() >= 2) {
                        uuids.add(UUID.fromString(String.format("%08x-0000-1000-8000-00805f9b34fb", buffer.getShort())));
                        length -= 2;
                    }
                    break;

                case 0x06: // Partial list of 128-bit UUIDs
                case 0x07: // Complete list of 128-bit UUIDs
                    while (length >= 16 && buffer.remaining() >= 16) {
                        long lsb = buffer.getLong();
                        long msb = buffer.getLong();
                        uuids.add(new UUID(msb, lsb));
                        length -= 16;
                    }
                    break;
            }

            if (length > buffer.remaining())
                break;
            buffer.position(buffer.position() + length);
        }

        return uuids;
    }

    private void scanStarted() {
        isScanning = true;
        scanTimeStamps.add(System.currentTimeMillis());
        scanInteractionCounter++;
        triggerOnScanStatusChanged(STARTED);
    }

    private void scanStopped() {
        isScanning = false;
        triggerOnScanStatusChanged(STOPPED);
    }

    // endregion

    // region Callback Wrappers

    private void triggerOnScanStatusChanged(final SuotaProfile.ScanStatus scanStatus) {
        MainThread.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                if (scanCallback != null)
                    scanCallback.onScanStatusChange(scanStatus);
            }
        });
    }

    private void triggerOnDeviceScan(final BluetoothDevice device, final int rssi, final byte[] scanRecord) {
        MainThread.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                if (scanCallback != null)
                    scanCallback.onDeviceScan(device, rssi, scanRecord);
            }
        });
    }

    private void triggerOnFailure(final SuotaProfile.ScanFailure failure) {
        MainThread.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                if (scanCallback != null)
                    scanCallback.onFailure(failure);
            }
        });
    }

    // endregion

    // region IScanner

    private IScanner getScannerApi19() {
        return new IScanner() {

            @SuppressLint("MissingPermission")
            @Override
            public void startScanning() {
                scanStarted();
                bluetoothAdapter.startLeScan(scanCallback19);
            }

            @SuppressLint("MissingPermission")
            @Override
            public void stopScanning() {
                scanStopped();
                bluetoothAdapter.stopLeScan(scanCallback19);
            }
        };
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private IScanner getScannerApi21() {
        return new IScanner() {
            BluetoothLeScanner bluetoothLeScanner;
            ScanSettings scanSettings;

            @SuppressLint("MissingPermission")
            @Override
            public void startScanning() {
                scanStarted();
                if (bluetoothLeScanner == null) {
                    bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
                    ScanSettings.Builder builder = new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).setReportDelay(0);
                    if (Build.VERSION.SDK_INT >= 26)
                        builder.setLegacy(false);
                    scanSettings = builder.build();
                }
                bluetoothLeScanner.startScan(null, scanSettings, scanCallback21);
            }

            @SuppressLint("MissingPermission")
            @Override
            public void stopScanning() {
                scanStopped();
                if (bluetoothLeScanner != null && bluetoothAdapter.isEnabled())
                    bluetoothLeScanner.stopScan(scanCallback21);
            }
        };
    }

    // endregion

    // region Scan Callbacks

    private BluetoothAdapter.LeScanCallback scanCallback19 = new BluetoothAdapter.LeScanCallback() {
        @Override
        public void onLeScan(final BluetoothDevice device, final int rssi, final byte[] scanRecord) {
            // Keep all devices
            if (!onlySUOTAUuidSearch && (uuids == null || uuids.size() == 0)) {
                triggerOnDeviceScan(device, rssi, scanRecord);
                return;
            }

            for (final UUID uuid : getUuidsFromAdvertisementData(scanRecord)) {
                // Keep only devices advertising SUOTA_SERVICE_UUID
                if (onlySUOTAUuidSearch && uuid.equals(SUOTA_SERVICE_UUID)) {
                    triggerOnDeviceScan(device, rssi, scanRecord);
                    break;
                }

                // Keep only devices advertising specific services uuids
                if (!onlySUOTAUuidSearch && uuids.contains(uuid)) {
                    triggerOnDeviceScan(device, rssi, scanRecord);
                    break;
                }
            }
        }
    };

    @RequiresApi(21)
    private ScanCallback scanCallback21 = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP ? null : new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            scanCallback19.onLeScan(result.getDevice(), result.getRssi(), result.getScanRecord() != null ? result.getScanRecord().getBytes() : new byte[0]);
        }

        @Override
        public void onBatchScanResults(List<ScanResult> results) {
            for (ScanResult result : results) {
                scanCallback19.onLeScan(result.getDevice(), result.getRssi(), result.getScanRecord() != null ? result.getScanRecord().getBytes() : new byte[0]);
            }
        }

        @Override
        public void onScanFailed(int errorCode) {
            scanStopped();
        }
    };

    // endregion

    // region IScanner Interface

    private interface IScanner {
        void startScanning();
        void stopScanning();
    }

    // endregion

    // region Builder

    /*
     * Using the builder pattern, we will not let the user create an SuotaScanner object
     * if he hasn't set some fields required for the class to function correctly
     */

    /**
     * Builder pattern is being used in order to create a SuotaScanner object.
     * The user can override some of the library's configuration.
     */
    public static class Builder {
        private Lifecycle lifecycle;
        private Bundle saveInstanceState;
        private AppCompatActivity activity = null;
        private RuntimePermissionChecker runtimePermissionChecker;
        private boolean skipLocationCheck;
        private boolean checkPermissions = SuotaLibConfig.CHECK_PERMISSIONS;
        private boolean allowDialogDisplay = SuotaLibConfig.ALLOW_DIALOG_DISPLAY;
        private long scanTimeout = SuotaLibConfig.Default.SCAN_TIMEOUT;

        /**
         * Mandatory to set!
         * Set the lifecycle of the "lifecycle owner" taking care of scanning
         * this will allow the library clean up everything needed.
         *
         * @param lifecycle the lifecycle
         * @return the current builder
         */
        public Builder setLifecycle(Lifecycle lifecycle) {
            this.lifecycle = lifecycle;
            return this;
        }

        /**
         * An activity must be set if the {@link #allowDialogDisplay allow dialog display flag}
         * or the {@link #checkPermissions check permissions flag} are set to <b>true</b>.
         *
         * @param activity activity
         * @return the current builder
         */
        public Builder setActivity(AppCompatActivity activity) {
            this.activity = activity;
            return this;
        }

        /**
         * Sets saveInstanceState. This is being used optionally only if permission checking is being handle by the library.
         * If set but the library doesn't handle the permission checking the value is being ignored.
         * But if the library handles permissions then this MUST be set otherwise the app will crash.
         *
         * @param saveInstanceState saveInstanceState
         * @return the current builder
         */
        public Builder setSaveInstanceState(Bundle saveInstanceState) {
            this.saveInstanceState = saveInstanceState;
            return this;
        }

        /**
         * Overrides the {@link com.dialog.suotalib.global.SuotaLibConfig#CHECK_PERMISSIONS default check permission flag}
         * just for this SuotaScanner object.
         *
         * @param checkPermissions if <b>true</b> the library will check and request permissions needed for the scan,
         *                         otherwise the user should use the SuotaScanner only if
         *                         the required permissions are granted
         * @return the current builder
         */
        public Builder setCheckPermissions(boolean checkPermissions) {
            this.checkPermissions = checkPermissions;
            return this;
        }

        public Builder setPermissionChecker(RuntimePermissionChecker runtimePermissionChecker) {
            this.runtimePermissionChecker = runtimePermissionChecker;
            return this;
        }

        /**
         * Decides if the Location Services permission is required in order to start scanning.
         *
         * @param skipLocationCheck if set to <b>true</b>, the library will allow start scanning
         *                          even if the Location Services are disabled but required
         * @return the current builder
         */
        public Builder setSkipLocationCheck(boolean skipLocationCheck) {
            this.skipLocationCheck = skipLocationCheck;
            return this;
        }

        /**
         * Overrides the {@link com.dialog.suotalib.global.SuotaLibConfig#ALLOW_DIALOG_DISPLAY default allow dialog display flag}
         * just for this SuotaScanner object.
         *
         * @param allowDialogDisplay if <b>true</b> the library will pop a fragment dialog
         *                           if any scan requirement is missing
         * @return the current builder
         */
        public Builder setAllowDialogDisplay(boolean allowDialogDisplay) {
            this.allowDialogDisplay = allowDialogDisplay;
            return this;
        }

        /**
         * Overrides the {@link com.dialog.suotalib.global.SuotaLibConfig.Default#SCAN_TIMEOUT default scan timeout}
         * just for this SuotaScanner object.
         *
         * @param scanTimeout scanTimeout
         * @return the current builder
         */
        public Builder setScanTimeout(long scanTimeout) {
            this.scanTimeout = scanTimeout;
            return this;
        }

        /**
         * Creates a SuotaScanner object.
         *
         * @return SuotaScanner object
         * returns null if the {@link #checkPermissions check permission flag} is set to <b>true</b> but there is not activity provided,
         * or if {@link #allowDialogDisplay allow dialog display flag} is set to <b>true</b> but there is not activity provided.
         */
        public SuotaScanner build() {
            if (lifecycle == null) {
                if (SuotaLibLog.SCAN_ERROR)
                    Log.e(TAG, "Cannot create SuotaScanner without providing a Lifecycle tied to it.");
                return null;
            }
            if (checkPermissions && activity == null) {
                if (SuotaLibLog.SCAN_ERROR)
                    Log.e(TAG, "Cannot create SuotaScanner without providing Activity when checkLocationPermission is enabled.");
                return null;
            }
            if (allowDialogDisplay && activity == null) {
                if (SuotaLibLog.SCAN_ERROR)
                    Log.e(TAG, "Cannot create SuotaScanner without providing Activity when ALLOW_DIALOG_DISPLAY is enabled. Dialogs may lead to starting activity for result, thus activity / context is mandatory to use dialogs feature.");
                return null;
            }

            return new SuotaScanner(this);
        }
    }

    // endregion
}
